Skip to content

feat(rules): emit RunfilesGroupInfo from py_binary, py_test, and py_runtime - #3962

Open
FrankPortman wants to merge 5 commits into
bazel-contrib:mainfrom
FrankPortman:runfiles_group_info
Open

feat(rules): emit RunfilesGroupInfo from py_binary, py_test, and py_runtime#3962
FrankPortman wants to merge 5 commits into
bazel-contrib:mainfrom
FrankPortman:runfiles_group_info

Conversation

@FrankPortman

@FrankPortman FrankPortman commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Implements #3757 against rules_runfiles_group 0.1.0 (the new entries-depset API).

When enabled, binaries emit these groups:

  • rules_python#runtime: interpreter + stdlib
  • rules_python#pypi/<name>: one per PyPI package, stable across whl repo names
  • rules_python#<label>: one Label named group per py_library
  • rules_python#venv: the binary-specific venv symlinks and support files
  • rules_python#app: the binary's own code, executable, bootstrap, and build data (executable_group = True)
  • data#<label>: rule targets from data attributes
  • Only py_binary/py_test/py_runtime emit the public provider, since only they can satisfy the union == default_runfiles contract (a py_library's srcs travel via PyInfo, not its own runfiles).
    • There is also a flag I didn't fully understand here (--srcs_as_runfiles) that I feel like is somehow implicated. Libraries propagate entries through a private provider.
    • The consequence is a foreign rule that takes in py_library may not get RGI behavior for free without its own shim. We can change this if we are willing to accept a change in semantics around union == default_runfiles (I think).
  • Gated by the upstream --@rules_runfiles_group//runfiles_group:enabled flag; --//python/config_settings:runfiles_groups defaults to following it and can override either way.
  • A binary overriding pyc_collection away from the config default, or using legacy implicit __init__.py, falls back to coarse runtime/venv/app grouping (dep entries are computed against the config, so the union would otherwise break).
    • Claude helped me find these edge cases - they were not in my initial understanding or sketch.

I had Claude patch the Bazel 9 vs Bazel 8 autoloads stuff just to get CI working, since I figure y'all will have a preference for how that is handled, and I can just adapt accordingly.

Fixes #3757


To implement this PR, I first looked at the reference implementation and browsed the rules_python enough to form an opinion on what a sketch might look like. I then steered Claude Fable pretty heavily in plan-mode, given a sketch/pseudocode of how I think it should look. Finally, I used Claude Fable to put finishing touches on comments, documentation, help me think of new test cases, and then stress test some edge cases.

I believe I understand this code well enough to respond to feedback and "own" the changes, conceptually, but I want to be transparent about LLM assistance.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@aignas aignas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I quite like the solution, thank you for submitting the PR. I think @rickeylev is doing some experiments with the venv building so I'll defer to him for final approval. The only thing is that I would love to keep the @rules_runfiles_group//runfiles_group:enabled an internal target and force the users to go through the main flag in rules_python. THis makes it easier to reason about the API surface.

For my understanding:

  • If this is disabled, the overhead would be minimal right? It's basically just an extra if check.
  • This is disabled by default?
  • Where does the rules_runfiles_group dependency comes from?

One last thing, if we could link to upstream docs on how one can create this layering rule for container image, that would be a big +1 from me.

Comment thread news/3757.added.md
Comment on lines +3 to +4
When enabled (via `--@rules_runfiles_group//runfiles_group:enabled` or
{obj}`--@rules_python//python/config_settings:runfiles_groups`), `py_binary`

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should advertise only one - the //python/config_settings one.

…untime

Implements bazel-contrib#3757: experimental support for the RunfilesGroupInfo
provider from rules_runfiles_group 0.1.0.

Emission is gated by two flags: the ecosystem-wide
--@rules_runfiles_group//runfiles_group:enabled switch, and
--//python/config_settings:runfiles_groups, whose default (auto)
follows the ecosystem switch and whose explicit values override it for
rules_python only. Off by default, zero cost when off.

When enabled, binaries split their runfiles into named groups so
packaging rules can build layered container images (shared interpreter
layer, one layer per package):

* rules_python#runtime: interpreter + stdlib (rank FOUNDATION,
  do_not_merge, kind foundation). py_runtime also emits this for its
  own runfiles.
* rules_python#pypi/<name>: one group per PyPI package, detected via
  the dist-info METADATA file so group names stay stable even though
  whl repo names aren't (rank SHARED_DEPS, kind third_party).
* One Label-named group per other py_library.
* rules_python#venv: the binary-specific venv symlinks and support
  files.
* rules_python#app: the binary's own code, executable, bootstrap and
  build data; named as the provider's executable_group.
* One Label-named group per rule target in data attributes.

Only binaries (and py_runtime) emit the public provider, because only
they can satisfy its contract that the union of all groups equals
DefaultInfo.default_runfiles exactly; analysis tests verify that. A
py_library's sources reach the binary through PyInfo rather than its
own runfiles, so libraries propagate their entries bottom-up through
the private PyRunfilesGroupsInfo provider instead: entries reference
dependencies' depsets rather than copying them, so per-target cost is
constant. A consequence worth maintainer review: a foreign ruleset's
binary with py_library deps gets no groups from them.

Binaries that override pyc_collection away from the configuration
default, or use the deprecated implicit __init__.py creation, fall
back to a coarse runtime/venv/app grouping so the invariant still
holds.

Fixes bazel-contrib#3757
The checked-in lockfile validated by bzlmod_lockfile_test needs the BCR
registry entries for rules_runfiles_group@0.1.0. Regenerated with the
command from the test's README under Bazel 9.1.0.
The test bzl loaded @rules_runfiles_group directly, but that repo is
only defined where the feature is available (bzlmod, Bazel 9+). Route
the loads through the runfiles_groups_shim like the rule code does.
The flag label from the shim is canonicalized via Label() so
rules_testing's transition can resolve it.
The generated py_library targets gained the _runfiles_groups_flag
implicit attribute, so add it to the expected dep set. The upstream
flag attribute is filtered out like toolchain types are: the shim
points it at @rules_runfiles_group only on Bazel 9+, and at
//python:none (already in the set) elsewhere.
@FrankPortman

Copy link
Copy Markdown
Contributor Author

Thanks for the review!

Re the flag, I deliberately built an interaction with the upstream flag but this definitely up for discussion. The upstream :enabled target is from rules_runfiles_group's ecosystem-wide switch (the rules_java integration in bazelbuild/rules_java#368 reads the same one). The Python flag's auto default follows it so a polyglot repo can turn on layering everywhere with one flag when a packaging rule needs it. Otherwise explicit enabled/disabled work as you might expect:

--//python/config_settings:runfiles_groups upstream :enabled=false (default) upstream :enabled=true
auto (default) no yes
enabled yes yes
disabled no no

My thought was that it would be nice to not have to opt individual rulesets in, if we're comfortable doing a top-down :enabled=true, but since this feature is new and experimental I could see it being reasonable to ONLY respect the rules_python flags. LMK which way y'all prefer, just wanted to clarify current behavior since it's buried in a long PR.

Your questions:

  • Overhead when disabled: yes, minimal - two implicit label attrs (the flag targets) plus a couple of if checks per target. No providers, depsets, or runfiles objects are created. Bazel 7/8 might be even more minimal but I don't fully understand the autoloads stuff there.
  • Disabled by default: yes. auto resolves to the upstream flag's default, which is off.
  • The dep comes from the BCR: bazel_dep on rules_runfiles_group 0.1.0 (the module that @malt3 linked in Emit RunfilesGroupInfo in py_binary targets #3757).

I'll add pointers to the rules_runfiles_group README ("for users" / "for packaging rule authors") and rules_img to the flag docs once that code is settled.

@aignas

aignas commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

I'd love @malt3 have a look as well, does this look idiomatic usage of the rules_runfiles_group?

@malt3

malt3 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Hey there. I'd like to give this a proper review. This week I'm on the go without a laptop. I'll be able to review early next week.

@FrankPortman FrankPortman left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

Adversarial review of the current head (c37ba6dd), findings inline. Summary and the parts that don't fit on a line:

Verdict. Not mergeable as-is: the public provider's core contract (union of groups == default_runfiles) is violated for any rule target in data whose default outputs aren't in its data_runfiles (verified empirically on Bazel 9.1.1; details on the collect_data_entries comment), and the test suite cannot detect it. The design choices themselves check out: the private PyRunfilesGroupsInfo seam, using data_runfiles for data, both coarse fallbacks (verified to hold via cquery), and the version-gated shim.

Structural feedback / simplifications.

  1. The groups are built by re-enumerating every runfiles contribution in parallel with the code that builds DefaultInfo runfiles, so the invariant holds by bookkeeping rather than by construction. The robust shape is to build the runtime/venv/app/dep pieces once in _get_base_runfiles_for_binary / _create_executable and derive both DefaultInfo and the groups from the same objects. This is the one item worth a maintainer opinion before acting, since it touches core runfiles construction.
  2. Fixing the blocker is a simplification: data handling collapses to files merged with data_runfiles for every target, and the singleton heuristic disappears.
  3. Deduplicate the pyc-selection rule with PycCollectionAttr and extract one shared "PyInfo files a binary adds" helper used by both the binary runfiles loop and the groups code.
  4. Drop the duplicated rank constants, inline the single-use provider wrapper, skip empty entries, and declare the flag attrs only when the feature is available.

Performance. Disabled mode: two extra implicit label attrs per py target plus a couple of branches, and one avoidable runfiles allocation per binary (venv_files_runfiles). Enabled mode: per-target cost is O(direct deps) with one entry and one depset node retained (linear, not quadratic, thanks to the 0.1.0 depset shape); no new actions. The place it can bite at scale is consumer-side: per-library granularity plus the empty entries mean a binary with N transitive libraries yields roughly N+5 groups, and a packager enforcing a layer budget pays upstream's pairwise merge, roughly O(G^2) in group count. Skipping empty entries helps now; a coarser (e.g. by-repo) granularity option is a reasonable follow-up.

Test gaps (listed inline on the test file): both coarse fallbacks, py_test, py_runtime, pyi_deps, precompile-enabled selection, py_binary-in-deps, foreign RunfilesGroupInfo deps, non-PyInfo deps, rule targets in srcs, --build_python_zip.

CI is currently red on the ruff format check (one file).

if RunfilesGroupInfo in target:
transitive.append(target[RunfilesGroupInfo].entries)
continue
runfiles = target[DefaultInfo].data_runfiles

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

blocker / correctness. Union of groups != default_runfiles for the common data shape. With Bazel's default --incompatible_always_include_files_in_data, ctx.runfiles(collect_default = True) adds a data rule target's default outputs in addition to its data_runfiles; this only adds data_runfiles (plus the singleton special case below), and the docstring asserts default outputs "contribute nothing". Verified on Bazel 9.1.1 with a rule returning DefaultInfo(files = depset([a, b])) and a rule with files != runfiles in data: their files land in default_runfiles and in no group, via py_binary data, py_library data, and inherited through py_binary-in-deps. Upstream's runfiles_groups.data_entry() gets this right (files merged with runfiles).

Suggested fix: for every target, content = ctx.runfiles(transitive_files = files).merge(data_runfiles); the singleton heuristic then becomes unnecessary. The pre-existing comment in common.bzl (~L301-306) that this mirrors is stale and should be fixed too.

]),
)

def _test_py_binary_groups(name):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

major / tests. The only union-equality test uses genrule and filegroup in data, both of which put their outputs in data_runfiles, so it cannot detect the blocker above. Add a Starlark rule with a multi-file DefaultInfo(files = ...) and no runfiles, and one with files != runfiles, in both py_binary data and py_library data, asserting union equality.


return providers

def _create_runfiles_groups(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

major / structure. Groups are produced by re-enumerating every runfiles contribution (required files, pyc variant, extra_default_outputs, stage2, build data, cc/native runfiles, venv pieces) independently of the code that builds the DefaultInfo runfiles (_get_base_runfiles_for_binary, _create_executable). The invariant holds today only by parallel bookkeeping; any future addition to app_runfiles / extra_runfiles silently breaks it with no test coverage.

Suggested direction: build the runtime/venv/app/dep pieces once in _get_base_runfiles_for_binary / _create_executable, return them in runfiles_details / exec_result, and derive both the DefaultInfo runfiles and the groups from those same objects.

load("//python:py_library.bzl", "py_library")
load("//python/private:runfiles_groups.bzl", "PyRunfilesGroupsInfo") # buildifier: disable=bzl-visibility

_tests = []

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

major / tests. Untested paths: both coarse fallbacks (legacy_create_init = 1, pyc_collection override), py_test, the py_runtime provider, pyi_deps, precompile enabled (implicit pyc vs source selection), py_binary-in-deps, a foreign RunfilesGroupInfo dep, a non-PyInfo dep in py_library (include_plain_dep_py_files), rule targets in srcs, --build_python_zip. The coarse paths were verified to hold via cquery, but nothing in the suite does. Each should get an analysis test asserting _assert_groups_cover_default_runfiles.


pypi_package, _ = _get_package_and_version(ctx)
own_entries = struct(
direct = [library_entry(ctx, pypi_package, own_files.build())],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

minor / correctness + performance. Every py_library emits an own entry even when it contributes nothing, and the non-PyInfo fallback emits entries with empty runfiles. Verified: every py_binary carries @@rules_python+//python/private:empty rank=-1 n=0 (the debugger alias resolving to a py_library); umbrella libraries and cc_library deps do the same. That's noise for packagers (empty layers unless the consumer filters) and pads the group count that feeds layer_budget merging. Skip own/fallback entries whose content is empty, as collect_src_entries already does.

}.items():
self.assertEqual(
{
self._normalize_label(x)
for x in self._read_file(var)
if not x.endswith("toolchain_type")
# The runfiles_groups shim points this attribute at

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

minor / CI. ruff format --check fails on this file ("Would reformat: tests/integration/whl_library/test_contents.py"), so the ruff check is red. Run ruff format on it.

bazel_10_or_later = str(bazel_major_version > 9),
))

if bazel_major_version >= 9:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

minor / hermeticity (acceptable as-is, flagging for the record). The shim is keyed on bazel_major_version >= 9 rather than on autoload availability: a Bazel 8 build with --incompatible_autoload_externally= (autoloads off) could load rules_runfiles_group but is stubbed anyway. Fine if documented as "Bazel 9+"; a version-independent key would need Bazel to expose the autoload setting to repository rules.

merge_affinity = _AFFINITY,
)

def venv_entry(runfiles):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

nit. The docstring says the venv group goes "late in the ordering", but rank -10 sorts before the per-library groups at rank -1, contradicting the module docstring's stated order (runtime < pypi < venv < libraries < app). Either fix the wording or swap the two ranks.

transitive.extend(c.transitive)
return runfiles_groups.entries(direct = direct, transitive = transitive)

def create_runfiles_group_info(entries, *, executable_group = None):

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

nit / terseness. create_runfiles_group_info is a one-line wrapper around the provider constructor with a single caller; inline it.

# Mirrors create_py_info: `.py` files of non-PyInfo deps are
# added to the library's transitive_sources. create_py_info
# already flattens this same depset in the same analysis, so
# the to_list() here is a cache hit, not a second flattening.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by Fable 5.1 on behalf of @FrankPortman requesting a Claude review.

nit. The comment claims this to_list() is "a cache hit, not a second flattening"; depset flattening isn't memoized per call in a way that makes that reliably true (unverified against Bazel source). Drop the claim and just note the cost mirrors create_py_info for the same rare non-PyInfo dep.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Emit RunfilesGroupInfo in py_binary targets

3 participants